如何设计一个即时通讯服务?
点击关注公众号,AI,编程干货及时送达
我们来设计一个即时通讯服务,像Facebook Messenger一样,用户可以通过网络和移动接口发送彼此的文本消息。
1. 什么是Facebook Messenger?
Facebook Messenger是一个软件应用程序,它为用户提供基于文本的即时通讯服务。Messenger用户可以通过手机和Facebook的网站与他们的Facebook好友进行聊天。
2. 系统的要求和目标
我们的Messenger应满足以下要求:
功能性要求:
1. Messenger应支持用户之间的一对一对话。
2. Messenger应跟踪用户的在线/离线状态。
3. Messenger应支持聊天记录的持久存储。
非功能性要求:
1. 用户应该能够体验到最小延迟的实时聊天。
2. 我们的系统应具有高度的一致性;用户应该能在所有设备上看到相同的聊天历史。
3. Messenger的高可用性是可取的;我们可以容忍在保证一致性的利益下的低可用性。
扩展要求:
• 群聊:Messenger应支持多人在群组中互相对话。
• 推送通知:Messenger应能够在用户离线时通知他们新的消息。
3. 容量估计和约束
假设我们有5亿每日活跃用户,平均每个用户每天发送40条消息;这就给我们每天200亿条消息。
存储估计:假设平均一条消息是100字节,所以存储一天的所有消息我们需要2TB的存储。
200亿条消息 * 100字节 => 2 TB/天
存储五年的聊天历史,我们需要3.6PB的存储。
2 TB * 365天 * 5年 ~= 3.6 PB
除了聊天消息,我们还需要存储用户信息,消息的元数据(ID、时间戳等)。更不用说,上述计算没有考虑到数据压缩和复制。
带宽估计:如果我们的服务每天收到2TB的数据,这将为我们每秒提供25MB的输入数据。
2 TB / 86400秒 ~= 25 MB/s
由于每个传入的消息都需要传给另一个用户,我们将需要同样数量的带宽25MB/s进行上传和下载。
高级估计:
4. 高级设计
在高级别上,我们将需要一个聊天服务器,这将是所有用户之间通讯的中心部分。当用户想向另一个用户发送消息时,他们将连接到聊天服务器并向服务器发送消息;然后服务器将该消息传递给其他用户并将其存储在数据库中。
详细的工作流程将如下:
1. 用户A通过聊天服务器向用户B发送消息。
2. 服务器接收到消息并向用户A发送确认信息。
3. 服务器将消息存储在其数据库中,并将消息发送给用户B。
4. 用户B接收到消息并向服务器发送确认信息。
5. 服务器通知用户A消息已成功发送给用户B。
5. 详细的组件设计
我们先试着构建一个所有功能都在一个服务器上运行的简单解决方案。在高级别上,我们的系统需要处理以下用例:
1. 接收传入的消息并发送出去的消息。
2. 从数据库存储和检索消息。
3. 记录哪个用户在线或已离线,并通知所有相关用户这些状态的改变。
让我们逐一讨论这些情况:
a. 消息处理
我们如何有效地发送/接收消息?发送消息,用户需要连接到服务器,并为其他用户发布消息。从服务器获取消息,用户有两个选择:
1. 拉取模式:用户可以定期向服务器查询是否有他们的新消息。
2. 推送模式:用户可以与服务器保持连接,并依赖于服务器在有新消息时通知他们。
如果我们采取第一种方法,那么服务器需要跟踪还未投递的消息,一旦接收用户连接到服务器询问是否有新消息,服务器可以返回所有等待的消息。为了减少用户的延迟,他们必须经常检查服务器,如果没有等待的消息,大部分时间他们将得到空响应。这将浪费大量的资源,看起来并不是一个高效的解决方案。
如果我们采取第二种方式,所有活跃的用户都与服务器保持一个开放的连接,那么一旦服务器收到消息,就可以立即将消息传递给预定的用户。这样,服务器就不需要跟踪等待的消息,我们将有最小的延迟,因为消息是通过开放的连接立即传递的。
客户端如何与服务器保持开放的连接?我们可以使用 HTTP 长轮询(HTTP Long Polling) 或 WebSocket。在长轮询中,客户端可以从服务器请求信息,期望服务器可能不会立即响应。如果服务器在收到轮询时对客户端没有新的数据,那么服务器不会发送空响应,而是保持请求开放,等待响应信息可用。一旦有新的信息,服务器立即向客户端发送响应,完成开放的请求。在收到服务器响应后,客户端可以立即向服务器请求未来的更新。这在延迟、吞吐量和性能方面有很大的改进。长轮询请求可能会超时,或者从服务器接收到断开的请求,在这种情况下,客户端必须打开一个新的请求。
服务器如何有效地跟踪所有已打开的连接以将消息重定向到用户?服务器可以维护一个哈希表,其中"键"是用户ID,"值"是连接对象。因此,每当服务器收到用户的消息时,它在哈希表中查找该用户,找到连接对象,并在打开的请求上发送消息。
当服务器为已经离线的用户收到消息时会发生什么?如果接收者已经断开连接,服务器可以通知发送者传送失败。如果是临时断开,例如,接收者的长轮询请求刚刚超时,那么我们应该期待用户重新连接。在这种情况下,我们可以要求发送者重试发送消息。这种重试可以嵌入到客户端的逻辑中,这样用户就不必重新输入消息。服务器也可以存储消息一段时间,并在接收者重新连接后重试发送。
我们需要多少聊天服务器?让我们计划在任何时候有5亿个连接。假设现代服务器在任何时候都能处理5万个并发连接,我们将需要1万个这样的服务器。
我们如何知道哪个服务器持有哪个用户的连接?我们可以在我们的聊天服务器前面引入一个软件负载均衡器;它可以将每个用户ID映射到一个服务器,以重定向请求。
服务器应如何处理 '传递消息' 的请求? 服务器在收到新消息时需要做以下几件事:1)将消息存储在数据库中 2)将消息发送给接收者 3)向发送者发送确认信号。
聊天服务器首先找到为接收者持有连接的服务器,并将消息传递给该服务器,以便发送给接收者。聊天服务器可以
然后向发送者发送确认信号;我们不需要等待将消息存储在数据库中(这可以在后台进行)。关于存储消息的内容将在下一节讨论。
通讯软件如何维护消息的顺序?我们可以在每条消息中存储一个时间戳,这是服务器接收到消息的时间。然而,这仍不能确保客户端消息的正确顺序。服务器时间戳不能确定消息精确顺序的场景可能是这样的:
1. 用户1向用户2发送一条消息M1。
2. 服务器在T1时间接收到消息M1。
3. 与此同时,用户2向用户1发送一条消息M2。
4. 服务器在T2时间接收到消息M2,其中T2 > T1。
5. 服务器向用户2发送消息M1,向用户1发送消息M2。
所以,用户1将首先看到M1,然后看到M2,而用户2将首先看到M2,然后看到M1。
为了解决这个问题,我们需要在每个客户端的每条消息中都保留一个序列号。这个序列号将确定每个用户的消息的确切顺序。使用这个解决方案,两个客户端将看到不同的消息序列视图,但这个视图在所有设备上对他们来说都是一致的。
b. 从数据库中存储和检索消息
每当聊天服务器接收到一条新消息时,它需要将其存储在数据库中。为此,我们有两个选择:
1. 启动一个单独的线程,该线程将与数据库一起工作以存储消息。
2. 向数据库发送异步请求以存储消息。
在设计我们的数据库时,我们需要记住一些事情:
1. 如何有效地使用数据库连接池。
2. 如何重试失败的请求。
3. 在一些重试之后仍然失败的请求应该记录在哪里。
4. 当所有的问题都解决了,如何重新尝试这些记录下来的请求(在重试后失败的)。
我们应该使用哪种存储系统?我们需要有一个数据库,它可以支持非常高速度的小更新,并且可以快速获取一系列的记录。这是因为我们有大量的小消息需要插入到数据库中,而在查询时,用户主要是对按顺序访问消息感兴趣的。
我们不能使用像MySQL这样的RDBMS或者像MongoDB这样的NoSQL,因为我们无法承受每次用户接收/发送消息时从数据库中读写一行的负担。这不仅会使我们服务的基本操作运行延迟高,而且还会对数据库产生巨大的负载。
我们的这两个要求都可以用像HBase这样的宽列数据库解决方案轻松满足。HBase是一个面向列的键值NoSQL数据库,可以将一个键对应多个列的多个值存储起来。HBase是模仿Google的BigTable设计的,并运行在Hadoop分布式文件系统(HDFS)之上。HBase将数据组织在一起,在内存缓冲区中存储新数据,一旦缓冲区满了,就将数据转储到磁盘上。这种存储方式不仅有助于快速存储大量的小数据,而且可以通过键获取行或扫描行范围。HBase还是一个存储可变大小数据的有效数据库,这也是我们的服务所需要的。
客户端应该如何有效地从服务器获取数据?客户端在从服务器获取数据时应进行分页。对于不同的客户端,页面大小可能会有所不同,例如,手机的屏幕较小,因此我们在视窗中需要更少的消息/对话。
c. 管理用户的状态
我们需要跟踪用户的在线/离线状态,并在状态变化时通知所有相关的用户。因为我们在服务器上为所有活动用户维护一个连接对象,所以我们可以轻易地从这里确定用户的当前状态。如果我们在任何时候有5亿活动用户,我们必须将每一次状态改变广播给所有相关的活动用户,这将消耗大量的资源。我们可以做以下的优化:
1. 每当一个客户端启动应用程序时,它可以拉取他们的好友列表中所有用户的当前状态。
2. 每当用户向已经离线的其他用户发送消息时,我们可以向发送者发送失败,并在客户端上更新状态。
3. 每当一个用户上线时,服务器总是可以延迟几秒钟广播那个状态,以查看用户是否立即离线。
4. 客户端可以从服务器拉取正在显示在用户视窗中的那些用户的状态。这应该不是一个频繁的操作,因为服务器正在广播用户的在线状态,我们可以容忍用户离线状态的陈旧性一段时间。
5. 每当客户端与另一个用户开始新的聊天时,我们可以在那时拉取状态。
设计总结:客户端会与聊天服务器打开一个连接来发送消息;然后服务器会将它传递给被请求的用户。所有的活动用户都将与服务器保持一个连接以接收消息。每当新消息到达时,聊天服务器会在长轮询请求上将它推送给接收用户。消息可以存储在HBase中,它支持快速小更新和基于范围的搜索。服务器可以向其他相关用户广播用户的在线状态。客户端可以不太频繁地为客户端视窗中可见的用户拉取状态更新。
6. 数据分区
因为我们将存储大量的数据(五年为3.6PB),我们需要将其分配到多个数据库服务器。我们的分区方案是什么?
基于UserID的分区:假设我们根据UserID的哈希值进行分区,以便我们可以在同一个数据库上保存用户的所有消息。如果一个DB分片是4TB,那么我们将有“3.6PB/4TB ~= 900”个分片用于五年的数据。为了简化,假设我们保留1K个分片。所以我们通过“hash(UserID) % 1000”找到分片号,然后从那里存储/检索数据。这种分区方案也可以很快地获取任何用户的聊天历史。
一开始,我们可以用较少的数据库服务器开始,一个物理服务器上可以有多个分片。因为我们可以在一个服务器上有多个数据库实例,所以我们可以很容易地在一个服务器上存储多个分区。我们的哈希函数需要理解这种逻辑分区方案,以便它可以在一个物理服务器上映射多个逻辑分区。
由于我们将存储无限的消息历史,我们可以从大量的逻辑分区开始,它们将被映射到较少的物理服务器上,随着我们的存储需求的增加,我们可以添加更多的物理服务器来分发我们的逻辑分区。
基于MessageID的分区:如果我们将用户的不同消息存储在不同的数据库分片上,获取聊天的一系列消息将会非常慢,所以我们不应采用这个方案。
7. 缓存
我们可以在用户的视窗中可见的一些最近的对话中缓存一些最近的消息(比如最后15条)。因为我们决定将所有用户的消息都存储在一个分片上,用户的缓存也应该完全存在于一台机器上。
8. 负载均衡
我们需要在我们的聊天服务器前面有一个负载均衡器;这个负载均衡器可以将每个UserID映射到为用户保持连接的服务器,然后将请求指向那个服务器。类似地,我们的缓存服务器也需要负载均衡。
9. 容错与复制
当聊天服务器失败时会发生什么?我们的聊天服务器正在与用户保持连接。如果服务器宕机,我们是否应该设计一种机制将这些连接转移到其他服务器?将TCP连接切换到其他服务器是极其困难的;一个更简单的方法可以是如果连接丢失,客户端自动重新连接。
我们应该存储用户消息的多份副本吗?我们不能只有用户数据的一份副本,因为如果持有数据的服务器崩溃或永久关闭,我们没有任何机制来恢复那些数据。对此,我们要么在不同的服务器上存储数据的多份副本,要么使用像Reed-Solomon编码这样的技术来分发和复制数据。
10. 扩展需求
a. 群聊
我们的系统中可以有独立的群聊对象,这些对象可以存储在聊天服务器上。群聊对象由GroupChatID标识,也会维护聊天中参与的人的列表。我们的负载均衡器可以根据GroupChatID来指导每个群聊消息,处理该群聊的服务器可以遍历聊天中的所有用户,找到处理每个用户连接的服务器以发送消息。
在数据库中,我们可以在一个根据GroupChatID分区的单独表中存储所有的群聊。
b. 推送通知
在我们当前的设计中,用户只能向在线的用户发送消息,如果接收用户离线,我们会向发送用户发送失败。推送通知将使我们的系统能够向离线用户发送消息。
对于推送通知,每个用户可以从他们的设备(或网页浏览器)选择接收新消息或事件的通知。每个制造商都维护一组服务器,负责将这些通知推送给用户。
要在我们的系统中使用推送通知,我们需要建立一个通知服务器,该服务器将接收离线用户的消息,并将它们发送到制造商的推送通知服务器,然后该服务器将它们发送到用户的设备。
推荐阅读
你好,我是拾叁,7年开发老司机、互联网两年外企5年。怼得过阿三老美,也被PR comments搞崩溃过。这些年我打过工,创过业,接过私活,也混过upwork。赚过钱也亏过钱。一路过来,给我最深的感受就是不管学什么,一定要不断学习。只要你能坚持下来,就很容易实现弯道超车!所以,不要问我现在干什么是否来得及。如果你还没什么方向,可以先关注我,这里会经常分享一些前沿资讯和编程知识,帮你积累弯道超车的资本。